Skip to content

feat: provision --local + gitignored adapter manifests + hardened path safety - #287

Open
aram356 wants to merge 154 commits into
mainfrom
feature/provision-local-impl
Open

aram356 wants to merge 154 commits into
mainfrom
feature/provision-local-impl

Conversation

@aram356

@aram356 aram356 commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator

Adds edgezero provision --local, folds all five adapter manifests into the gitignored-generated model, and hardens the surrounding CLI (path safety, env redaction, adapter-scoped env-file load) across provision, config push, config diff, and serve.

Source artifacts

The 9 plan sections all landed on this branch (rather than as separate sub-PRs merging in); each closes on merge below.

What ships

provision --local. New ProvisionMode::Local arm threads through Adapter::provision. Local mode:

  • Synthesises minimal per-adapter manifests on a clean clone via toml_edit::DocumentMut (CLI-owned bootstrap runs before validation).
  • Merges per-store bindings + env-label lines (EDGEZERO__STORES__<KIND>__<ID>__NAME=…) so the runtime resolves stores at startup.
  • Writes adapter-specific env files (.edgezero/.env, .dev.vars, <spin_crate>/.env).
  • Never shells out to cloud CLIs.

Dry-run stages a real fs::copy into a tempfile::TempDir and diffs the result back. The tree stays byte-identical either way.

Typed provision (run_provision_typed::<C>). Generated <app>-cli binaries dispatch the typed variant, which runs the base flow then additionally emits per-secret placeholder lines derived from AppConfig's #[secret] / #[secret(store_ref)] fields (Axum: <key>=; Cloudflare: <key>="" in .dev.vars; Fastly: [[local_server.secret_stores.<store_id>]]; Spin: lowercased [variables] + SPIN_VARIABLE_<NAME>= in .env). The bundled edgezero binary intentionally does not emit placeholders — it has no downstream AppConfig type.

All five adapter manifests are provision-generated and gitignored. axum.toml joined wrangler.toml / fastly.toml / spin.toml / runtime-config.toml in the 2026-07 amendment; the scaffold .hbs template for axum.toml was removed so scaffold-time provision is the single writer. A CI grep gate enforces the whole set.

Path safety. New path_safety::{assert_provision_paths_safe, assert_provision_paths_contained} helpers guard every CLI entry point that joins manifest_root with an operator-declared adapter path. Cloud dispatch runs the "safe" variant (absolute-path rejection + .. traversal rejection); --local runs the stricter "contained" variant that additionally requires BOTH [adapters.<name>.adapter].manifest AND [adapters.<name>.adapter].crate to be declared, with the manifest resolving inside the crate dir. Wired into run_provision, run_provision_typed, run_config_push_typed, run_config_diff_typed, and run_serve.

Renamed / nested adapter crate support. cli_support::read_adapter_crate_name walks upward from the manifest's parent to the first Cargo.toml inside manifest_root and reads [package].name. All four bundled adapter synthesisers use this to name generated manifests correctly under operator renames ([adapters.axum.adapter].crate = "crates/server" → crate = "server" in axum.toml; Cargo package spin-server → wasm path spin_server.wasm). Nested manifests like crates/server/config/spin.toml resolve correctly too.

Spin component / crate decoupling. synthesise_spin_toml now takes two distinct identities — crate_name (from Cargo.toml, drives [application].name and the wasm source basename) and component (from [adapters.spin.adapter].component, drives [[trigger.http]].component and the [component.<id>] table key). Fixes the pre-2026-07 conflation where setting a component selector would silently mispoint the wasm artifact.

Env-value redaction in dry-run. .env and .dev.vars bodies are rewritten as KEY=<redacted> before diffing so operator secrets never surface in dry-run output. Commented KEY=value lines (adapter provisioners emit these as # EDGEZERO__STORES__…__KEY= placeholders, and operators stash real values behind # for later use) get the same treatment; pure comments and blank lines pass through so structural drift stays readable.

Adapter-scoped env-file load in run_serve. Axum reads <manifest_root>/.edgezero/.env; Spin reads .env next to the resolved spin.toml (matches where provision writes it — a nested manifest = "crates/spin/config/spin.toml" correctly resolves to crates/spin/config/.env). Both run through the path-safety guard before the read. Contract test spawns a real serve child, inspects its inherited environment via a file the child writes, and asserts the marker propagates.

Section breakdown

Each section closes on merge:

Post-plan additions (from external review iterations)

Multiple review passes drove refinements past the original plan:

  • Axum folded into gitignored-generated set (v4 review) — scaffold template removed; .gitignore + CI gate + spec-plan-guide docs updated.
  • read_adapter_crate_name upward walk added to support nested manifests (v5 review).
  • Spin [application].name and wasm basename decoupled from the component selector (v5 review).
  • Path safety extended: strict-local requires both .manifest AND .crate; run_config_diff_typed gets the guard (previously read-only justified skipping); run_serve gets the guard on the Spin env-file load.
  • Env-value redaction added to dry-run diff output, including commented # KEY=value lines (v6/v7 reviews).
  • Typed dry-run runs the full run_manifest_shape_gates (adds validate_deployed_field_ownership alongside capability + handler-path checks).
  • Spin run_serve derives .env path from [adapters.spin.adapter].manifest.parent() (was .crate — broke on nested manifests). Contract test observes the spawned child's env (v8 review).
  • Non-test #[expect(...)] restructure sweep across edgezero-cli, edgezero-core/manifest.rs (Deserialize impls), and adapter cli/mod.rs files (removed via explicit trait method overrides, ConfigPushSuppressions sub-struct, stream module, structural code fixes rather than workspace-level allows).

CI gates (all pass locally)

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo test --workspace --all-targets
  • cargo check --workspace --all-targets --features "fastly cloudflare spin"
  • cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin
  • examples/app-demo workspace tests
  • Generated-project ignored compile test (cargo test -p edgezero-cli --test generated_project_builds -- --ignored)

Test plan

  • All five CI gates pass on the merged branch
  • Per-adapter contract tests cover the four provision_local_* cases + Spin's env-label alignment quartet (Section 9)
  • Renamed / nested adapter crate regression tests across all four adapters
  • Spin nested-manifest serve env contract test (parent env + child-observed env)
  • Dry-run env / secret file redaction regression tests (raw values, commented KEY=value shapes, structural drift)
  • Path-safety negative tests for run_serve, run_config_diff_typed, run_config_push_typed, run_provision
  • Worktree is clean after workspace + examples/app-demo tests

Migration notes

  • Local development requires re-running <app>-cli provision --adapter <name> --local after cloning to regenerate the five adapter manifests (.gitignore covers all of them plus .dev.vars).
  • [adapters.<name>.adapter].manifest and [adapters.<name>.adapter].crate are now both required for provision --local and config push/diff --local. Every scaffolded project already sets both. Cloud dispatch remains permissive.
  • Downstream CLIs generated from the scaffold now dispatch via run_provision_typed::<AppConfig> (not the untyped run_provision) so #[secret] fields reach the adapters' provision_typed impls.

Empty tracking commit for the implementation work tracked in:
  docs/superpowers/plans/2026-06-27-provision-local.md

Issues:
  - Epic: <epic-issue-url>
  - Per-section sub-issues linked from the epic.

This PR opens as a DRAFT and stays draft until Section 1 lands its
first real commit. Each section opens as its own follow-up PR
that lands here before the umbrella merges to main.
@aram356 aram356 self-assigned this Jun 30, 2026
aram356 added 14 commits June 30, 2026 00:46
run_shared_checks iterates every declared adapter and dispatches
validate_adapter_manifest, which for Spin does
fs::read_to_string(manifest_root.join(rel)). With the containment
guard sitting after run_shared_checks, a manifest declaring
[adapters.spin.adapter].manifest = "../outside/spin.toml" could
trigger a filesystem read outside the project root before the
guard rejected it — a spec violation of §"Path containment (MUST)"
which requires the helper run BEFORE any manifest-path use.

Fix by relocating the check to fire immediately after
load_push_context, and looping over every declared adapter (not
just ctx.adapter) since run_shared_checks reads all of them.
Also close Task 7's Minor about the duplicate adapter_entry call
by removing the now-redundant per-adapter guard block.

Regression test: config_push_local_rejects_parent_traversal_in_
sibling_spin_adapter declares a poisoned Spin adapter alongside
the pushed axum adapter, and asserts the error names the
containment violation (not Spin's "failed to read spin manifest"
message that would surface under the old ordering).

Also tighten copy_tree's else-branch to explicitly gate on
is_regular_file() rather than "everything non-dir non-symlink",
add a Unix symlink-skip test, and drop a stale
#[expect(dead_code)] on ValidationContext::manifest_path that
now has real callers.
The bare-cwd variant of the accept-test (--manifest edgezero.toml)
previously wrote the manifest to a tempdir and set EDGEZERO_MANIFEST,
but run_provision reads args.manifest directly (no env fallback).
The test therefore failed on manifest load ("failed to load
edgezero.toml") and its negative !contains(path-safety-markers)
assertion vacuously passed — no actual coverage of the
`args.manifest.parent() == ""` fallback.

Fix by adding a CwdGuard RAII helper that chdirs into the tempdir
under the manifest_guard() serialisation lock and restores the
previous cwd on drop. Both accept-tests now also assert positively
that the error is the (true, true) dispatch stub ("local dry-run
staging lands in Task 10/11"), proving the manifest loaded AND
path-safety passed AND we reached the dispatch matrix. Drop the
now-unnecessary EnvOverride from both tests.

Reviewer: reviewer of Task 9 pushed this as a Low ahead of Task 10
because run_with_staging depends on manifest-root/cwd correctness.
aram356 added 12 commits August 13, 2026 16:08
The dependabot syn 2->3 update refreshed the ROOT Cargo.lock but not the
excluded `examples/app-demo` workspace's lock, so its CI step
(`cargo test --workspace --all-targets --locked`) failed with 'cannot
update the lock file ... --locked was passed'. Regenerate app-demo's lock
(adds syn 3.0.3) so it matches its manifests; all app-demo tests pass
under --locked.
…ules

Reconciles the branch with origin/main (Fastly chunked-config GC feature
#314 plus dependency bumps). The main feature — operator-invoked
`config gc` that reclaims orphaned Fastly config-store chunk entries — was
added to the monolithic `cli.rs` on main, while this branch had split that
file into a `cli/` module directory (matching every other adapter). The
feature is ported into the split structure:

- cli/gc.rs (new): the reclamation core (list/classify/plan/execute,
  generation proofs, RFC3339 age gating, recovery-command reporting).
- cli/mod.rs: the `gc_config_entries` + `preflight_config_write` trait
  methods and the shared manifest-lock / atomic-replace / key-hygiene
  guards.
- push_cloud / push_local / provision_local / provision_cloud: main's GC
  write-path changes (gc_roots tracking, orphan prune, listing-based
  fail-closed reads, manifest locking) hand-merged onto the branch's
  versions, PRESERVING the branch fixes (reserved runtime-env store guard,
  child-reaping stdin writes, "wrote local ..." status verbs, the
  not-yet-provisioned refusal, the 255-char key limit).
- chunked_config.rs taken from main (GC exports + 255 limit).
- The other adapters (axum/cloudflare/spin) get explicit no-op
  `gc_config_entries`/`preflight_config_write` impls for the
  `missing_trait_methods` lint.
- Two main GC test fixtures updated to the branch's "manifest resolves
  inside the adapter crate" layout; the malformed-TOML dry-run case (which
  the branch deliberately refuses, consistent with the real push) dropped
  from the "reaches the writer" set.
- app-demo Cargo.lock synced for the syn 3 bump.

All gates green: fmt, clippy --all-features -D warnings, workspace tests,
feature check, spin wasm32, app-demo --locked, docs build/format/lint,
shellcheck, smoke backup tests. Fastly cli 252 tests, edgezero-cli 302.
The merge auto-merged `examples/app-demo/Cargo.lock` into a state cargo
could not parse ("failed to parse lock file"), because origin/main's GC
feature adds `rusqlite` (and siblings) to that excluded workspace and the
three-way lock merge left it inconsistent. Regenerate it so
`cargo test --locked` parses and resolves cleanly.
The ported fastly `cli` test modules import/use `#[cfg(unix)]` fixtures
(fake-`fastly` PATH shims, `path_mutation_guard`) whose imports were left
ungated. On the host (unix) everything compiles, but the CI `fastly wasm
clippy` matrix (`--features "fastly cli"` and `--features fastly` on
wasm32-wasip1, which is NOT unix, with --all-targets) saw the cfg(unix)
helpers vanish and the imports/one test dangle -- 9 unresolved/unused-import
errors. Align the `#[cfg(unix)]` gating on those imports (and the one test
that calls `path_mutation_guard`) so the import set matches what compiles on
a non-unix target. No imports deleted; each stays available on unix.

Verified clean: fastly clippy on {fastly cli, fastly} x wasm32-wasip1, cli
+ no-default on host; fastly tests (252 cli / 53 default); plus cloudflare
+ spin wasm clippy and app-demo fmt/clippy.
…ects

Two P0 blockers (both live-verified):
- Spin build no longer TRUNCATES its own artifact. `refresh_declared_source`
  compared lexical paths, so the default `source` (`../../target/.../x.wasm`,
  a relative path with `..`) never matched the normalised artifact and
  `fs::copy` copied the file onto itself -> 0 bytes. Now guarded by a
  canonicalize-based same-file check (also for the conventional refresh), plus
  containment (refuse a `source` resolving outside the workspace or through a
  symlink) and single-component matching (#1, #5).
- Fresh Cloudflare scaffolds can build/serve/deploy. The synthesised
  `wrangler.toml` now declares `[build] command = "worker-build --release"`
  (main points at the worker-build-generated shim), and the adapter drops its
  `[commands]` shell block so build/serve/deploy route through the registry
  dispatch, which passes `--config <manifest>` (also fixes custom manifest
  paths being ignored) (#2, #7).

Spin:
- `[adapters.spin.adapter].cloud = true` is the SOLE Fermyon Cloud selector;
  the legacy deploy-command heuristic and its `manifest_adapter_deploy_cmd`
  field are removed, and `config diff` threads the flag like `config push` (#3).
- `edgezero serve` forwards parent-shell `EDGEZERO__*` overrides to the guest
  via `spin up --env` (highest precedence) so a shell override reaches the
  sandboxed component (#4).
- deploy uses `spin cloud deploy` (bare `spin deploy` needs a plugin on
  Spin 3+/4) (#6).

Cloudflare / Axum (via subagents):
- typed secret keys are validated for `.dev.vars` round-trip (empty/=/#/
  newline/whitespace) (#16); local push refuses a `.wrangler` symlink escape
  (#11); axum runtime honours `EDGEZERO_MANIFEST` when locating `.edgezero`
  (#8).

Fastly:
- cloud provision now writes the per-store `__NAME` runtime mappings into
  `edgezero_runtime_env` so a renamed cloud store is reachable (#10); the
  dry-run preflight redacts malformed-manifest parse errors and models the
  real writer's inline-toml rejection (#13).

Convergence (via subagent): reprovisioning converges managed `__NAME`
overlays across env_file + all four adapters via a new
`upsert_lines_with_header` (#9).

CLI:
- dry-run staging refuses a TMPDIR inside the project (self-recursive copy)
  and no longer follows a symlinked live path into the diff output (#12);
- cloud provision preflights manifest writability before mutating the account,
  so an unwritable manifest can't orphan a created resource (#15).
R23 fixed a three-level composed-deploy DEADLOCK by making a borrowing
deploy hold NO lock -- but a borrowing deploy DOES write `fastly.toml`
(`fastly compute deploy` records the service_id), so a lock-free deploy
borrower races a co-sibling provision and loses an edit.

Replace the single shared `provision.borrow.lock` with a DEPTH-KEYED
sibling lock: a borrower serialises its file writes against its co-siblings
on `provision.borrow.<depth>.lock`, and a deploy advertises `depth + 1`
to its subprocess (`EDGEZERO_PROVISION_SIBLING_DEPTH`). A more-nested
borrower therefore serialises on the NEXT depth's file and never blocks on
an ancestor's sibling lock -- holding serialisation at EVERY level with no
deadlock, and making borrowing deploys serialise like provisions. (#14)
…tainment refinements

- cloudflare: run deploy/serve from the crate dir with an absolute --config
  and a depth-aware `main` relpath so a nested wrangler config resolves the
  worker-build shim correctly
- spin: honour the manifest's adapter component selector when refreshing the
  declared source, reject intermediate symlinks + self-copy, and forward the
  guest --env overlay without panicking on non-Unicode env keys; deploy via
  `spin cloud deploy`
- axum: absolute()-normalise before .parent() in manifest_root_from_env
- fastly: converge every store's runtime name mapping on cloud provision;
  local push/provision dry-run degrades over unprovisioned/malformed prior
  state instead of hard-failing (matches the chunked-config GC contract)
- provision lock: compare advertised vs calculated lock targets by canonical
  path, and re-authenticate after a sibling borrow so a stale token can't
  bypass an unrelated holder
- provision dry-run: diff the "before" side against a T1 staging snapshot
  instead of re-reading the live worktree at render time, so a concurrent
  external rewrite can't smear two worktree instants into one report;
  redaction now tracks multiline quoted env values
- docs: cloudflare required [build], cloud=true selector, `spin cloud deploy`,
  fastly secrets via env var
- smoke scripts: share a single backup/provision-local helper
Integrates upstream's layered deploy actions + Fastly staging lifecycle
(#316), optional typed secret paths + Fastly store mappings (#344),
runtime_env_config for custom Fastly entry points (#351), and dependency
bumps, reconciled against this branch's provision-local work.

Conflict resolutions:
- adapters (cloudflare/spin/axum): added the 4 Fastly-only staging-action
  reject arms (DeployStaged/EmitVersion/Healthcheck/Rollback) to each
  `cli/mod.rs`; kept our `cli/` module restructure over upstream's
  monolithic `cli.rs`.
- fastly: re-homed upstream's ~4300-line staging lifecycle into a new
  `cli/staging.rs` wired to the 4 execute arms, reusing our existing
  module helpers (RUNTIME_ENV_STORE_NAME, ConfigStoreLookup,
  populate_runtime_name_mappings, find_fastly_manifest, ...) instead of
  duplicating them.
- adapter.rs: merged our `build_child_env` (provision-lock advertisement +
  required-secrets assertion + env overlay) into upstream's
  `build_shell_command`/`run_shell`/`run_shell_tee` split; `execute_capture`
  is now overlay-aware.
- lib.rs: `run_deploy` now acquires the deploy provision lock and threads
  its advertisement overlay through upstream's staged/capture/production
  dispatch; kept upstream's new run_healthcheck/run_rollback/
  run_active_version. Extracted `acquire_deploy_lock`.
- config.rs: kept our read-phase lock release before the consent prompt
  and upstream's emit-outputs-on-skip; unioned ConfigPushArgs bools
  (added `staging`). Extracted `assert_push_adapter_paths`.
- args/main/template: merged the `--staging`/`Rollback` additions with our
  `--local`/typed-provision changes.

Verified: fmt, clippy --workspace --all-targets --all-features, workspace
tests, feature check, spin wasm32-wasip2, app-demo --locked, docs
lint, shellcheck.
`run_with_staging_refuses_a_tempdir_inside_the_project` overrode the
process-global `TMPDIR` while holding `env_lock()` -- a DIFFERENT mutex
from the `manifest_guard()` that the other ~93 env-mutating tests in the
`edgezero-cli` binary take. Two disjoint mutexes over one global
environment serialise nothing.

`tempfile::TempDir::new()` reads `TMPDIR`, so concurrent tests had their
tempdirs created inside this test's `inside-tmp` directory, which it then
deleted on drop. The victim failed with a confusing
`No such file or directory (os error 2)` on a path like
`.../<outer>/inside-tmp/<inner>/edgezero.toml`, and because it panicked
while holding `manifest_guard`, it POISONED that mutex -- cascading into
30-47 collateral `manifest guard: PoisonError` failures that disguised
the single root cause.

Take `manifest_guard()` instead, per that guard's documented contract.

Also correct `env_lock`'s doc: "one lock per test binary is sufficient"
is only true when a binary uses ONE lock. `edgezero-cli` defines three,
so the guidance as written invited exactly this bug.

Verified: reproduced at ~1/10 stress runs (16 threads) before the change,
including a captured leaked-tempdir path; 0/10 after. Full workspace
tests 4x (20/20 binaries, no panics), fmt, clippy --workspace
--all-targets --all-features, feature check, spin wasm32-wasip2,
shellcheck.
…ing the smoke fixture

Three independent CI failures on the merge commit, none caught locally
because the gate set I had been running was incomplete.

1. `fastly wasm clippy` (format.yml matrix): `unused import:
   tempfile::tempdir` in cli/staging.rs. Every `tempdir()` call site is
   inside a `#[cfg(unix)]` test that PATH-shims a fake `fastly`, so the
   import is dead on wasm32-wasip1. Gate it with `#[cfg(unix)]` like its
   sibling imports. A host-target `clippy --all-features` never compiles
   this target, which is why it passed locally.

2. `lifecycle-smoke` (deploy-action.yml): a staged deploy against a
   COMMANDS-ONLY adapter was rejected with "`[adapters.fastly.adapter]
   .manifest` is not declared". `assert_adapter_declaration_consistent`
   deliberately permits an adapter that declares NEITHER `.manifest` nor
   `.crate` (it owns its build/deploy and never uses declared-path
   discovery), but `resolve_declared_adapter_paths` hard-errored on the
   missing `.manifest`. A `deploy --staging` bypasses
   `commands.<action>` and drives the vendor CLI, so it reaches that
   resolver. Return `(None, None)` for the neither-declared case --
   exactly as for an undeclared adapter -- so the adapter falls back to
   its own discovery. Half-declared adapters are still rejected.

3. `cache-smoke` / `config-push-smoke` (deploy-action.yml): "committed
   source is required; the working tree for 'fixture-app' is dirty" on
   the SECOND deploy. `run_deploy` now holds the cross-process provision
   lock, which creates `<project>/.edgezero/provision.lock`. Every real
   project gitignores the whole `.edgezero/` directory (this repo's
   .gitignore and the scaffolder's gitignore.hbs both do), but the smoke
   fixture predates the lock and hand-rolls a minimal ignore list, so the
   lock file showed up as untracked. Add `.edgezero/` to the fixture's
   .gitignore.

The `cargo test` job's failure was the TMPDIR guard leak already fixed in
022b933 (its log shows the same `inside-tmp` leaked path); no further
change needed here.

Verified: deploy-core contract tests 260/260, smoke_backup_test 37/37,
workspace tests 20/20 binaries, full wasm matrix (fastly wasip1 with
`fastly` and `fastly cli`, cloudflare wasm32-unknown-unknown, spin
wasip2), fastly cli-only and no-default-features clippy,
check_no_nested_app_config, app-demo fmt + clippy --locked, workspace
fmt + clippy --all-features, shellcheck.
Let adapters classify sensitive TOML artifacts for fail-closed preview
redaction, and omit source excerpts from native provision parse errors.
Use the existing Fastly manifest lock and atomic replacement for both
base and typed local provision so deploy writeback cannot be erased.

Normalize child application manifest selectors before changing cwd,
accept root adapter crates, and guard Spin artifact and SQLite paths.
Cover the reported failures with secret-sentinel, subprocess locking,
child execution, and public adapter symlink regressions.
@aram356 aram356 added this to the 202611 milestone Oct 29, 2026

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 PR Review

Reviewed dac4838544eea394d0f3ddd870d90e3c10ebd299 against local main (fc4ccba0ee57). The adapter-owned dispatch and shared local provisioning design have substantial regression coverage, but the reproduced containment, confidentiality, feature-build, and integration defects below need correction. Findings 1–10 and 12–14 are inline.

Blocking cross-cutting finding

🔧 [11] Migrate Spin cloud consumers to the explicit selector
Locations: docs/guide/cli-walkthrough.md:345 and scripts/smoke_test_config_key_override.sh:631–644.

Cloud config dispatch now requires [adapters.spin.adapter].cloud = true; the old deploy-command heuristic is no longer consulted. Neither the walkthrough's cloud flow nor the smoke's demo manifest sets that selector. For applications declaring config stores, the walkthrough therefore pushes to local SQLite before deploying to Cloud. The “Cloud Unsupported” smoke likewise targets local state, or accepts a missing-manifest error as its expected unsupported result. Its push occurs after the per-adapter restoration.

Fix: document setting cloud = true before the cloud push, replace the walkthrough's remaining deploy-command heuristic description, and run the cloud smoke with an isolated, explicitly cloud-selected manifest.

Praise

😃 [15] Strong preservation coverage
crates/edgezero-adapter/src/env_file.rs:455–475 checks exact bytes across multiline quote delimiters, CRLF, managed-looking assignments inside values, and missing final newlines. These tests directly protect against secret-file corruption.

Checks

📝 Required/local gates

  • Formatting: PASS
  • All-targets, all-features workspace Clippy with -D warnings: PASS
  • Workspace/all-targets tests: PASS
  • Combined Fastly/Cloudflare/Spin feature compilation: PASS
  • Spin wasm32-wasip2 compilation: PASS
  • Diff whitespace check: PASS

📝 Additional checks

  • Standalone audit-feature compilation: FAIL, as described in finding 4.
  • Minimal-feature Clippy: FAIL (unused CLI imports and an unfulfilled lint expectation).
  • Focused scratch probes reproduced the safety issues, Cargo-resolution mismatches, auth prerequisite regression, Wrangler bulk-operation ambiguity, Spin identifier rejection, output-error handling, and descendant-survival behavior.
  • Windows, authenticated cloud operations, full emulator smoke suites, and the ignored generated-project build test remain unverified.
  • No repository source files were modified during review.

📝 Totals: 13 🔧 required changes, 1 ♻️ non-blocking suggestion, 1 😃 praise item.

fs::create_dir_all(parent)
.map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
}
fs::copy(artifact, &src_path).map_err(|err| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 [1] Write to the destination that passed the containment check

reject_symlink_components checks normalized, but create_dir_all and fs::copy operate on src_path. For source = "escaped/../victim.wasm", where escaped links to an external directory's child, normalization checks an in-project path while the actual filesystem traversal writes the external victim.wasm. A scratch CLI build with a successful fake Cargo command reproduced the external overwrite.

Fix: perform filesystem operations on the exact guarded, normalized destination, or reject symlinks in the original path before collapsing parent components. Add a symlink-plus-.. regression.

let snapshot_id = manifest_id(&snapshot)?;
let startup = tempfile::tempdir().map_err(|err| err.to_string())?;
// Fastly reads this before parsing --dir/--env and may upgrade it.
fs::write(startup.path().join("fastly.toml"), &snapshot).map_err(|err| err.to_string())?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 [2] Keep the private startup manifest owner-only

This copies the complete source manifest, including inline secret data, without preserving its access restrictions. With umask 022, I reproduced a 0600 source becoming a 0644 startup copy inside a 0755 temporary directory during native deployment. Other local users can read that copy. The separate NamedTempFile does not protect this startup file.

Fix: create the startup directory with owner-only permissions (0700 on Unix), or create its manifest as 0600 from the outset. Add a regression using a restricted source and umask 022.

if let Some(dir) = target_dir_from_args(build_args) {
return CargoTargetDir::Explicit(resolve_dir_against(crate_dir, &dir));
}
if let Some(dir) = config_target_dir_from_args(crate_dir, build_args) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 [3] Match Cargo's target-directory resolution before selecting artifacts

Two verified cases disagree with Cargo:

  • With CARGO_TARGET_DIR=env-dir and --config 'build.target-dir="arg-dir"', Cargo builds into env-dir, but this resolver selects arg-dir.
  • The loops at lines 191–203 consult .cargo/config.toml when the extensionless .cargo/config exists but has no target directory. Cargo ignores that sibling file entirely. This also affects the CARGO_HOME lookup.

The adapter artifact lookups trust this result exclusively, so a successful build can fail discovery or package a stale artifact from the incorrectly selected directory. Scratch Cargo metadata/build probes confirmed both mismatches.

Fix: check CARGO_TARGET_DIR after --target-dir but before --config; keep CARGO_BUILD_TARGET_DIR below --config. Select one configuration filename per directory before inspecting its contents, then continue to ancestors/global configuration when that selected file supplies no target directory.

@@ -53,6 +52,7 @@ use std::process;
use std::result::Result;
use std::string::ToString;

use edgezero_cli::stream::{info_line, stdout_line};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 [4] Preserve standalone audit-feature compilation

edgezero_cli::stream is gated behind cli, but this binary requires only nested-app-config-check. The following previously supported feature combination now fails with E0432:

cargo check -p edgezero-cli --bin check_no_nested_app_config \
  --no-default-features --features nested-app-config-check

Fix: expose stream when either cli or nested-app-config-check is enabled, and add this focused feature-combination check. The all-features gate cannot detect this regression.

Comment thread crates/edgezero-cli/src/adapter.rs Outdated
// env the shell path above would have applied, otherwise a `serve`
// here starts with none of the `.env` secrets and resolves its
// manifest from the process cwd.
with_exec_context(adapter_name, manifest_loader, env_overlay, |ctx| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 [5] Do not require application runtime prerequisites for authentication

The registry-backed auth path now uses with_exec_context, which validates required application secrets and resolves adapter paths before invoking login/logout/status. Authentication does not require those runtime secrets or application files. I reproduced edgezero auth status --adapter axum failing solely because an application secret was unset; the same dispatch also serves vendor authentication. Partial path declarations are rejected by the unconditional check above as well.

Fix: make secret/path prerequisites action-aware. Built-in authentication should be able to run before application runtime configuration is complete. Preserve the resolved child environment without imposing unrelated build/serve prerequisites.

Comment thread scripts/smoke_test_config.sh Outdated
smoke_require_port_free "$PORT"
# `EnvSecretStore` resolves the `demo_api_token` key verbatim from the
# process env, so /config/typed's secret walk needs it exported.
(cd "$DEMO_DIR" && demo_api_token="$DEMO_SECRET_VALUE" \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 [9] Export the secret variable the Axum reader actually uses

The newly selected /config/typed endpoint requires api_token, whose configured key is demo_api_token. EnvSecretStore looks that up as EDGEZERO__SECRETS__DEMO_API_TOKEN, not the bare demo_api_token exported here. In a clean environment the endpoint therefore fails secret resolution rather than returning the expected greeting.

Fix: launch with:

EDGEZERO__SECRETS__DEMO_API_TOKEN="$DEMO_SECRET_VALUE"

The key-override smoke already uses the correctly prefixed namespace.

Comment thread scripts/lib/smoke_backup.sh Outdated
fi
done
fi
pkill -TERM -P "$pid" 2>/dev/null || true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 [10] Stop all owned descendants before restoring state

pkill -P "$pid" terminates only direct children. Grandchildren are handled only when lsof finds them holding the selected port, so without lsof, or for non-listening descendants, cleanup returns while they remain alive. Callers then restore operator files underneath surviving writers.

I reproduced a wrapper → shell → Node tree where the grandchild survived smoke_stop_server, then changed the restored state from original to survivor-flush.

Fix: capture the complete owned descendant tree before terminating ancestors, terminate and wait for those descendants, and only then permit backup restoration. Port ownership alone is insufficient.

Some("has leading or trailing whitespace")
} else if key.starts_with('#') {
Some("starts with `#`, which `.env` readers treat as a comment")
} else if key.to_ascii_uppercase().starts_with(RESERVED_PREFIX) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 [12] Validate the derived Axum secret name, not a raw reserved prefix

A raw key such as EDGEZERO__ADAPTER__HOST cannot collide with the runtime host setting: both provisioning and EnvSecretStore map it to EDGEZERO__SECRETS__EDGEZERO__ADAPTER__HOST. A current-source probe successfully read that credential without setting adapter-host configuration, while this typed validation rejected the same key.

This blocks previously supported configurations during typed validation, provisioning, push, and diff.

Fix: remove the raw-key EDGEZERO__ prefix rejection. Retain the shared character-set and derived-name collision checks, and replace the rejection test with a round-trip test.

Comment thread docs/guide/cli-walkthrough.md Outdated

`config push` still never writes secret **values** — the blob stores each
`#[secret]` field's key NAME, not its resolved value. Set the value at run time
via `SPIN_VARIABLE_<NAME>=<value>` (or `spin up --env <NAME>=<value>`).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 [13] Supply a Spin application variable, not a component environment variable

spin up --env NAME=value sets a component's WASI environment. It does not supply the application variable referenced by [component.<id>.variables] and read through spin_sdk::variables by SpinSecretStore. The generated secret therefore remains at its empty default when the documented alternative is used.

Fix: retain SPIN_VARIABLE_<NAME>=<value>, or document Spin's --variable name=value option instead of --env.

Comment thread crates/edgezero-cli/src/provision.rs Outdated
[adapters.cloudflare.adapter]
crate = "crates/cf"
manifest = "crates/cf/wrangler.toml"
deployed = { kv_namespaces = { sessions = "old-id" } }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ [14] Make the inline-table fixtures exercise existing inline state

This deployed value is still inside [adapters.cloudflare.adapter], not [adapters.cloudflare]. Similarly, the fixture at line 4224 puts its supposedly root-level adapters value under [app]. Parsing the current fixtures confirms both intended writeback locations are absent before the operation, so the tests create missing tables rather than update existing inline tables.

Suggestion: move deployed into the adapter table and place the root inline adapters assignment before [app]. Assert the intended inline tables exist before invoking writeback. This is non-blocking test-fidelity feedback.

Use the guarded Spin copy destination and private Fastly startup directories.
Match Cargo target resolution, select Wrangler namespaces explicitly, and
validate Spin component IDs independently of Cargo artifact names. Preserve
Axum secrets whose derived environment names do not collide with config.

Separate authentication from runtime prerequisites and propagate payload
output errors through diff, push and audit results. Preserve audit-only and
minimal-feature builds. Correct inline-table and TOML preview coverage.

Stop all captured smoke descendants before restoring state, isolate explicit
Spin cloud smoke manifests, and fix documented secret setup. Add focused
regressions and CI checks for the reported failures.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment